@@ -1,9 +1,9 @@ |
||
1 |
-require 'open3' |
|
2 |
- |
|
3 | 1 |
module Agents |
4 | 2 |
class ShellCommandAgent < Agent |
5 | 3 |
default_schedule "never" |
6 | 4 |
|
5 |
+ can_dry_run! |
|
6 |
+ |
|
7 | 7 |
def self.should_run? |
8 | 8 |
ENV['ENABLE_INSECURE_AGENTS'] == "true" |
9 | 9 |
end |
@@ -11,7 +11,7 @@ module Agents |
||
11 | 11 |
description <<-MD |
12 | 12 |
The Shell Command Agent will execute commands on your local system, returning the output. |
13 | 13 |
|
14 |
- `command` specifies the command to be executed, and `path` will tell ShellCommandAgent in what directory to run this command. |
|
14 |
+ `command` specifies the command (either a shell command line string or an array of command line arguments) to be executed, and `path` will tell ShellCommandAgent in what directory to run this command. The content of `stdin` will be fed to the command via the standard input. |
|
15 | 15 |
|
16 | 16 |
`expected_update_period_in_days` is used to determine if the Agent is working. |
17 | 17 |
|
@@ -20,6 +20,10 @@ module Agents |
||
20 | 20 |
|
21 | 21 |
The resulting event will contain the `command` which was executed, the `path` it was executed under, the `exit_status` of the command, the `errors`, and the actual `output`. ShellCommandAgent will not log an error if the result implies that something went wrong. |
22 | 22 |
|
23 |
+ If `suppress_on_failure` is set to true, no event is emitted when `exit_status` is not zero. |
|
24 |
+ |
|
25 |
+ If `suppress_on_empty_output` is set to true, no event is emitted when `output` is empty. |
|
26 |
+ |
|
23 | 27 |
*Warning*: This type of Agent runs arbitrary commands on your system, #{Agents::ShellCommandAgent.should_run? ? "but is **currently enabled**" : "and is **currently disabled**"}. |
24 | 28 |
Only enable this Agent if you trust everyone using your Huginn installation. |
25 | 29 |
You can enable this Agent in your .env file by setting `ENABLE_INSECURE_AGENTS` to `true`. |
@@ -31,7 +35,7 @@ module Agents |
||
31 | 35 |
{ |
32 | 36 |
"command": "pwd", |
33 | 37 |
"path": "/home/Huginn", |
34 |
- "exit_status": "0", |
|
38 |
+ "exit_status": 0, |
|
35 | 39 |
"errors": "", |
36 | 40 |
"output": "/home/Huginn" |
37 | 41 |
} |
@@ -41,6 +45,8 @@ module Agents |
||
41 | 45 |
{ |
42 | 46 |
'path' => "/", |
43 | 47 |
'command' => "pwd", |
48 |
+ 'suppress_on_failure' => false, |
|
49 |
+ 'suppress_on_empty_output' => false, |
|
44 | 50 |
'expected_update_period_in_days' => 1 |
45 | 51 |
} |
46 | 52 |
end |
@@ -50,6 +56,16 @@ module Agents |
||
50 | 56 |
errors.add(:base, "The path, command, and expected_update_period_in_days fields are all required.") |
51 | 57 |
end |
52 | 58 |
|
59 |
+ case options['stdin'] |
|
60 |
+ when String, nil |
|
61 |
+ else |
|
62 |
+ errors.add(:base, "stdin must be a string.") |
|
63 |
+ end |
|
64 |
+ |
|
65 |
+ unless Array(options['command']).all? { |o| o.is_a?(String) } |
|
66 |
+ errors.add(:base, "command must be a shell command line string or an array of command line arguments.") |
|
67 |
+ end |
|
68 |
+ |
|
53 | 69 |
unless File.directory?(options['path']) |
54 | 70 |
errors.add(:base, "#{options['path']} is not a real directory.") |
55 | 71 |
end |
@@ -75,38 +91,62 @@ module Agents |
||
75 | 91 |
if Agents::ShellCommandAgent.should_run? |
76 | 92 |
command = opts['command'] |
77 | 93 |
path = opts['path'] |
94 |
+ stdin = opts['stdin'] |
|
95 |
+ |
|
96 |
+ result, errors, exit_status = run_command(path, command, stdin) |
|
78 | 97 |
|
79 |
- result, errors, exit_status = run_command(path, command) |
|
98 |
+ payload = { |
|
99 |
+ 'command' => command, |
|
100 |
+ 'path' => path, |
|
101 |
+ 'exit_status' => exit_status, |
|
102 |
+ 'errors' => errors, |
|
103 |
+ 'output' => result, |
|
104 |
+ } |
|
80 | 105 |
|
81 |
- vals = {"command" => command, "path" => path, "exit_status" => exit_status, "errors" => errors, "output" => result} |
|
82 |
- created_event = create_event :payload => vals |
|
106 |
+ unless suppress_event?(payload) |
|
107 |
+ created_event = create_event payload: payload |
|
108 |
+ end |
|
83 | 109 |
|
84 |
- log("Ran '#{command}' under '#{path}'", :outbound_event => created_event, :inbound_event => event) |
|
110 |
+ log("Ran '#{command}' under '#{path}'", outbound_event: created_event, inbound_event: event) |
|
85 | 111 |
else |
86 | 112 |
log("Unable to run because insecure agents are not enabled. Edit ENABLE_INSECURE_AGENTS in the Huginn .env configuration.") |
87 | 113 |
end |
88 | 114 |
end |
89 | 115 |
|
90 |
- def run_command(path, command) |
|
91 |
- result = nil |
|
92 |
- errors = nil |
|
93 |
- exit_status = nil |
|
94 |
- |
|
95 |
- Dir.chdir(path){ |
|
96 |
- begin |
|
97 |
- stdin, stdout, stderr, wait_thr = Open3.popen3(command) |
|
98 |
- exit_status = wait_thr.value.to_i |
|
99 |
- result = stdout.gets(nil) |
|
100 |
- errors = stderr.gets(nil) |
|
101 |
- rescue Exception => e |
|
102 |
- errors = e.to_s |
|
116 |
+ def run_command(path, command, stdin) |
|
117 |
+ begin |
|
118 |
+ rout, wout = IO.pipe |
|
119 |
+ rerr, werr = IO.pipe |
|
120 |
+ rin, win = IO.pipe |
|
121 |
+ |
|
122 |
+ pid = spawn(*command, chdir: path, out: wout, err: werr, in: rin) |
|
123 |
+ |
|
124 |
+ wout.close |
|
125 |
+ werr.close |
|
126 |
+ rin.close |
|
127 |
+ |
|
128 |
+ if stdin |
|
129 |
+ win.write stdin |
|
130 |
+ win.close |
|
103 | 131 |
end |
104 |
- } |
|
105 | 132 |
|
106 |
- result = result.to_s.strip |
|
107 |
- errors = errors.to_s.strip |
|
133 |
+ (result = rout.read).strip! |
|
134 |
+ (errors = rerr.read).strip! |
|
135 |
+ |
|
136 |
+ _, status = Process.wait2(pid) |
|
137 |
+ exit_status = status.exitstatus |
|
138 |
+ rescue => e |
|
139 |
+ errors = e.to_s |
|
140 |
+ result = ''.freeze |
|
141 |
+ exit_status = nil |
|
142 |
+ end |
|
108 | 143 |
|
109 | 144 |
[result, errors, exit_status] |
110 | 145 |
end |
146 |
+ |
|
147 |
+ def suppress_event?(payload) |
|
148 |
+ (boolify(interpolated['suppress_on_failure']) && payload['exit_status'].nonzero?) || |
|
149 |
+ (boolify(interpolated['suppress_on_empty_output']) && payload['output'].empty?) |
|
150 |
+ end |
|
111 | 151 |
end |
112 | 152 |
end |
@@ -5,19 +5,31 @@ describe Agents::ShellCommandAgent do |
||
5 | 5 |
@valid_path = Dir.pwd |
6 | 6 |
|
7 | 7 |
@valid_params = { |
8 |
- :path => @valid_path, |
|
9 |
- :command => "pwd", |
|
10 |
- :expected_update_period_in_days => "1", |
|
11 |
- } |
|
8 |
+ path: @valid_path, |
|
9 |
+ command: 'pwd', |
|
10 |
+ expected_update_period_in_days: '1', |
|
11 |
+ } |
|
12 |
+ |
|
13 |
+ @valid_params2 = { |
|
14 |
+ path: @valid_path, |
|
15 |
+ command: [RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"'], |
|
16 |
+ stdin: "{{name}}", |
|
17 |
+ expected_update_period_in_days: '1', |
|
18 |
+ } |
|
12 | 19 |
|
13 |
- @checker = Agents::ShellCommandAgent.new(:name => "somename", :options => @valid_params) |
|
20 |
+ @checker = Agents::ShellCommandAgent.new(name: 'somename', options: @valid_params) |
|
14 | 21 |
@checker.user = users(:jane) |
15 | 22 |
@checker.save! |
16 | 23 |
|
24 |
+ @checker2 = Agents::ShellCommandAgent.new(name: 'somename2', options: @valid_params2) |
|
25 |
+ @checker2.user = users(:jane) |
|
26 |
+ @checker2.save! |
|
27 |
+ |
|
17 | 28 |
@event = Event.new |
18 | 29 |
@event.agent = agents(:jane_weather_agent) |
19 | 30 |
@event.payload = { |
20 |
- :cmd => "ls" |
|
31 |
+ 'name' => 'Huginn', |
|
32 |
+ 'cmd' => 'ls', |
|
21 | 33 |
} |
22 | 34 |
@event.save! |
23 | 35 |
|
@@ -27,6 +39,7 @@ describe Agents::ShellCommandAgent do |
||
27 | 39 |
describe "validation" do |
28 | 40 |
before do |
29 | 41 |
expect(@checker).to be_valid |
42 |
+ expect(@checker2).to be_valid |
|
30 | 43 |
end |
31 | 44 |
|
32 | 45 |
it "should validate presence of necessary fields" do |
@@ -47,7 +60,7 @@ describe Agents::ShellCommandAgent do |
||
47 | 60 |
|
48 | 61 |
describe "#working?" do |
49 | 62 |
it "generating events as scheduled" do |
50 |
- stub(@checker).run_command(@valid_path, 'pwd') { ["fake pwd output", "", 0] } |
|
63 |
+ stub(@checker).run_command(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] } |
|
51 | 64 |
|
52 | 65 |
expect(@checker).not_to be_working |
53 | 66 |
@checker.check |
@@ -60,7 +73,9 @@ describe Agents::ShellCommandAgent do |
||
60 | 73 |
|
61 | 74 |
describe "#check" do |
62 | 75 |
before do |
63 |
- stub(@checker).run_command(@valid_path, 'pwd') { ["fake pwd output", "", 0] } |
|
76 |
+ stub(@checker).run_command(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] } |
|
77 |
+ stub(@checker).run_command(@valid_path, 'empty_output', nil) { ["", "", 0] } |
|
78 |
+ stub(@checker).run_command(@valid_path, 'failure', nil) { ["failed", "error message", 1] } |
|
64 | 79 |
end |
65 | 80 |
|
66 | 81 |
it "should create an event when checking" do |
@@ -70,6 +85,42 @@ describe Agents::ShellCommandAgent do |
||
70 | 85 |
expect(Event.last.payload[:output]).to eq("fake pwd output") |
71 | 86 |
end |
72 | 87 |
|
88 |
+ it "should create an event when checking (unstubbed)" do |
|
89 |
+ expect { @checker2.check }.to change { Event.count }.by(1) |
|
90 |
+ expect(Event.last.payload[:path]).to eq(@valid_path) |
|
91 |
+ expect(Event.last.payload[:command]).to eq([RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"']) |
|
92 |
+ expect(Event.last.payload[:output]).to eq('hello, world.') |
|
93 |
+ expect(Event.last.payload[:errors]).to eq('warning!') |
|
94 |
+ end |
|
95 |
+ |
|
96 |
+ describe "with suppress_on_empty_output" do |
|
97 |
+ it "should suppress events on empty output" do |
|
98 |
+ @checker.options[:suppress_on_empty_output] = true |
|
99 |
+ @checker.options[:command] = 'empty_output' |
|
100 |
+ expect { @checker.check }.not_to change { Event.count } |
|
101 |
+ end |
|
102 |
+ |
|
103 |
+ it "should not suppress events on non-empty output" do |
|
104 |
+ @checker.options[:suppress_on_empty_output] = true |
|
105 |
+ @checker.options[:command] = 'failure' |
|
106 |
+ expect { @checker.check }.to change { Event.count }.by(1) |
|
107 |
+ end |
|
108 |
+ end |
|
109 |
+ |
|
110 |
+ describe "with suppress_on_failure" do |
|
111 |
+ it "should suppress events on failure" do |
|
112 |
+ @checker.options[:suppress_on_failure] = true |
|
113 |
+ @checker.options[:command] = 'failure' |
|
114 |
+ expect { @checker.check }.not_to change { Event.count } |
|
115 |
+ end |
|
116 |
+ |
|
117 |
+ it "should not suppress events on success" do |
|
118 |
+ @checker.options[:suppress_on_failure] = true |
|
119 |
+ @checker.options[:command] = 'empty_output' |
|
120 |
+ expect { @checker.check }.to change { Event.count }.by(1) |
|
121 |
+ end |
|
122 |
+ end |
|
123 |
+ |
|
73 | 124 |
it "does not run when should_run? is false" do |
74 | 125 |
stub(Agents::ShellCommandAgent).should_run? { false } |
75 | 126 |
expect { @checker.check }.not_to change { Event.count } |
@@ -78,7 +129,7 @@ describe Agents::ShellCommandAgent do |
||
78 | 129 |
|
79 | 130 |
describe "#receive" do |
80 | 131 |
before do |
81 |
- stub(@checker).run_command(@valid_path, @event.payload[:cmd]) { ["fake ls output", "", 0] } |
|
132 |
+ stub(@checker).run_command(@valid_path, @event.payload[:cmd], nil) { ["fake ls output", "", 0] } |
|
82 | 133 |
end |
83 | 134 |
|
84 | 135 |
it "creates events" do |
@@ -89,6 +140,13 @@ describe Agents::ShellCommandAgent do |
||
89 | 140 |
expect(Event.last.payload[:output]).to eq("fake ls output") |
90 | 141 |
end |
91 | 142 |
|
143 |
+ it "creates events (unstubbed)" do |
|
144 |
+ @checker2.receive([@event]) |
|
145 |
+ expect(Event.last.payload[:path]).to eq(@valid_path) |
|
146 |
+ expect(Event.last.payload[:output]).to eq('hello, Huginn.') |
|
147 |
+ expect(Event.last.payload[:errors]).to eq('warning!') |
|
148 |
+ end |
|
149 |
+ |
|
92 | 150 |
it "does not run when should_run? is false" do |
93 | 151 |
stub(Agents::ShellCommandAgent).should_run? { false } |
94 | 152 |
|